從 Closure 更進一步理解 JS 運作


Posted by saffran on 2021-02-25

Closure 是什麼?

console.log(a) 會是 11

function test() {
  var a = 10;
  function inner() {
    a++;
    console.log(a); // 11
  }
  inner(); // 呼叫 inner function
}

test();

閉包就是:在一個 function 裡面,return 另一個 function

範例:
function test 裡面,return function inner

用一個變數 func 去接收「test() 所回傳的 inner function」
console.log(a) 一樣會是 11

function test() {
  var a = 10;
  function inner() {
    a++;
    console.log(a); // 11
  }
  return inner; // 回傳 inner function
}

var func = test(); // 接收 inner 這個 function
func(); // 等同於是「呼叫 inner function」

如果再次呼叫 func()console.log(a) 就依序會是 11, 12, 13

function test() {
  var a = 10;
  function inner() {
    a++;
    console.log(a);
  }
  return inner; // 回傳 inner function
}

var func = test(); // 接收 inner 這個 function
func(); // 等同於是「呼叫 inner function」
func();
func();

output:

11
12
13

為什麼會叫做「閉包」呢?

以上面範例為例:
當進入到 function test 時,會產生 test EC 並建立 test VO

test EC
test VO = {
  a: 10,
  inner: func
}

之前有說過,當 function 執行結束後,所有資源都會被釋放掉
照理來說,當執行到第七行 return inner 之後,function test 就執行完畢了,在 test VO 裡面的資源都會被清空

但是卻發現在 return function 時,return 的 function inner 不知道為什麼,可以把「在 test VO 裡面的 a 給記起來」(a 就像是一直被鎖在 function inner 裡面)

只有在 function inner 裡面可以存取到 a 的值,其他地方都存取不到。也可以透過 function inner 去改變 a 的值

Closure 應用的範例:避免一直重複計算

例如,有一個 function complex 每次都要做很複雜的計算
如果是按照以前的作法,我每 call 一次 function complex,都要重新做一次複雜的計算才能得到結果

但是,我可以利用閉包的特性,來避免一直重複計算:

我把 function complex 傳進 function cache 後,cache(complex) 會 return 一個新的 function -> 所以,變數 cachedComplex 就是這個 return 的 function,所以就可以接收一個 num 的參數

利用 var ans = {} 這個物件,把值記起來
function cache 裡面 return 的 function,會把 ans 的值給記幾來,我就可以一直重複運用它

  • 第一次 call cachedComplex(20) 時:因為是第一次執行,所以會進行計算
  • 第二次 call cachedComplex(20) 時:因為前面已經計算過了,所以不用重新計算,可以直接輸出結果 -> cachedComplex 利用「閉包」的特性,把值給記起來
function complex(num) {
  // 複雜的計算
  console.log("calculate"); // 有出現 "calculate" 代表「真的有執行 function complex」
  return num * num * num;
}

// 會回傳一個新的 function
function cache(func) {
  var ans = {}; // 用這個物件,把值記起來
  return function (num) {
    if (ans[num]) {
      // 如果 ans[num] 有東西的話,就直接回傳 ans[num] 的 value
      return ans[num];
    }

    // 如果 ans[num] 沒東西的話
    ans[num] = func(num); // 第一次執行時,會執行到這裡 ans[20] = complex(20)
    return ans[num];
  };
}

console.log(complex(20)); // 進行一次複雜的計算
console.log(complex(20)); // 進行一次複雜的計算
console.log(complex(20)); // 進行一次複雜的計算

const cachedComplex = cache(complex);

console.log(cachedComplex(20)); // 因為是第一次,所以會需要執行 complex(20)
console.log(cachedComplex(20)); // 利用閉包的特性,不需計算就可以直接回傳 ans[20] 的值
console.log(cachedComplex(20)); // 利用閉包的特性,不需計算就可以直接回傳結果 ans[20] 的值

每一次執行 cachedComplex(20),其實就是在執行下面這段:
會記住變數 ans 的值

  return function (20) {
    if (ans[20]) {
      // 如果 ans[20] 有東西的話,就直接回傳 ans[20] 的 value
      return ans[20];
    }

    // 如果 ans[20] 沒東西的話
    ans[20] = func(20); // 第一次執行時,會執行到這裡 ans[20] = complex(20)
    return ans[20];
  };

從 ECMAScript 看作用域

每一個 EC 都有一個 scope chain。當進入一個 EC 時,scope chain 就會被建立

什麼是 AO (Activation Object)?

Activation Object 和 Variable Object 做的事情是一樣的,都是拿來存放相關的資訊,只是在 global EC 裡面稱作 VO,在 function EC 裡面稱作 AO

當進入到 global EC 後,VO 會被建立,裡面存放所有相關的資訊

當進入到一個 function EC 後,AO 會被新增,裡面也是存放所有相關的資訊(有一個預設的屬性是 arguments

再次 cosplay JS 引擎

var v1 = 10;
function test() {
  var vTest = 20;
  function inner() {
    console.log(v1, vTest); // 10 20
  }
  return inner;
}

var inner = test();
inner();

用上面這段程式碼為例,我們假裝自己是 JS 的引擎,看看背後是怎麼實際執行的:

1. 首先,會進入 globalEC

在 globalEC 裡面:

  • 初始化 VO:
    • 變數 v1 被初始化成 undefined
    • 變數 inner 被初始化成 undefined
    • test 是一個 function
  • 設定 globalEC 的 scopeChain 會是 [globalEC.VO]

設定 function test[[Scope]] 屬性,會把「上一層的 scopeChain 給複製進來」,也就是 [globalEC.VO]

globalEC: {
  VO: {
    v1: undefined,
    inner: undefined,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = [globalEC.VO]

2. 接著,開始執行 globalEC 的程式碼

第 1 行 var v1 = 10;,會把 globalEC.VO 裡面的 v1 變成 10
第 10 行 var inner = test();,會執行 function test,就進入了 testEC

3. 進入 testEC

testEC 裡面:

  • 初始化 AO:
    • 變數 vTest 被初始化成 undefined
    • inner 是一個 function
  • 設定 testECscopeChain 會是「自己的 AO」+「自己的 [[Scope]] 屬性」,也就是 [testEC.AO, globalEC.VO]

設定 inner[[Scope]] 屬性,會把「上一層的 scopeChain 給複製進來」,也就是 [testEC.AO, globalEC.VO]

testEC: {
  AO: {
    vTest: undefined,
    inner: func
  },
  scopeChain: [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    v1: 10,
    inner: undefined,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = [globalEC.VO]

4. 開始執行 testEC 的程式碼

執行第 3 行 var vTest = 20;vTest 的值更新成 20

當執行到第 7 行 return inner; 時,照理來說,testEC 執行完畢了,testECtestAO 就要被清空

可是,因為 function inner 被 return 回去,在 inner.[[Scope]] 會需要引用到 testEC.AOglobalEC.VO,所以 JS 底層的垃圾回收機制就不能把 testEC.AOglobalEC.VO 回收掉,testEC.AOglobalEC.VO 都會被存在 function inner 裡面

scopeChain[[Scope]] 和 Closure 之間的關係,就是 Closure 的原理:因為保留了 scopeChain,所以在離開了前一個 function 後,還是可以存取的到前一個 function 的 AO 的值

因為 function 的 [[Scope]] 屬性會把「要存取的 scopeChain」給記起來,當我進入 function 的 EC 時,再去初始化 scopeChainscopeChain 會需要用到前面幾層的 AOVO,所以那些 AOVO 必須被保留起來

testEC 執行完畢後,testEC 就被 pop 掉了(testEC 可以回收),但 testEC.AO 要保留起來

inner.[[Scope]] = [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    v1: 10,
    inner: func,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = [globalEC.VO]

testEC.AO: {
  vTest: 20,
  inner: func
}

5. 進入 innerEC

innerEC 裡面:

  • 初始化 AO:
    • AO 裡面沒有任何東西
  • 設定 innerECscopeChain 會是「自己的 AO」+「自己的 [[Scope]] 屬性」,也就是 [innerEC.AO, testEC.AO, globalEC.VO]
inner.[[Scope]] = [testEC.AO, globalEC.VO]

innerEC: {
  AO: {

  },
  scopeChain: [innerEC.AO, testEC.AO, globalEC.VO]
}

globalEC: {
  VO: {
    v1: 10,
    inner: func,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = [globalEC.VO]

testEC.AO: {
  vTest: 20,
  inner: func
}

6. 開始執行 innerEC 的程式碼

執行這行 console.log(v1, vTest); 時,會在自己的 scopeChain 裡面一層一層往上找:

  • 先找 v1

    • innerEC.AO 裡面,找不到 v1
    • testEC.AO 裡面,找不到 v1
    • globalEC.VO 裡面,找到 v1: 10,所以就會輸出 10
  • 再找 vTest

    • innerEC.AO 裡面,找不到 vTest
    • testEC.AO 裡面,找到 vTest: 20,所以就會輸出 20

Closure 要注意的地方:有可能會保留到「我不會用到的值」

假設,我在 function test 裡面宣告了一個很巨大的物件 var obj = { huge object };

var v1 = 10;
function test() {
  var vTest = 20;
  var obj = { huge object }; // 一個很巨大的物件
  function inner() {
    console.log(v1, vTest); // 10 20
  }
  return inner;
}

var inner = test();
inner();

testEC 執行完畢後,這個很巨大的物件還是會隨著 testEC.AO 被帶到 innerECscopeChain 裡面

雖然我在 function inner 裡面完全不會用到這個巨大的物件,但它還是沒辦法被回收掉,因為 obj: { huge object } 就是屬於 testEC.AO 的一部分

inner.[[Scope]] = [testEC.AO, globalEC.VO]

innerEC: {
  AO: {

  },
  scopeChain: [innerEC.AO, testEC.AO, globalEC.VO]
}

globalEC: {
  VO: {
    v1: 10,
    inner: func,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = [globalEC.VO]

testEC.AO: {
  vTest: 20,
  inner: func,
  obj: { huge object } // 這個很巨大的物件還是會存在
}

日常生活中的作用域陷阱

常見陷阱一

var arr = [];
for (var i = 0; i < 5; i++) {
  arr[i] = function () {
    console.log(i);
  };
}

arr[0](); // 呼叫 arr[0] 這個 function,會印出 5

當我呼叫 arr[0]() 時,我以為會輸出的是 0,但結果是輸出 5

原因為:
因為是在 global 宣告 var i = 0,所以變數 i 就是一個全域變數(等同於是下面這樣寫)

var arr = [];
var i; // i 是全域變數
for (i = 0; i < 5; i++) {
  arr[i] = function () {
    console.log(i);
  };
}

arr[0](); // 呼叫 arr[0] 這個 function,會印出 5

迴圈的每一圈都會建立一個 function,只是還沒執行:

// 當迴圈跑第一圈時
arr[0] = function () {
    console.log(i);
  };

// 當迴圈跑第二圈時
arr[1] = function () {
    console.log(i);
  };

... 以此類推

當執行到第 9 行 arr[0]() 時,就是執行 arr[0] 這個 function,那在 function 裡面的 console.log(i),要怎麼找到這個 i 呢?

因為在自己的 function scope 沒有 i,所以會往上層的 scope(也就是 global scope)去找到這個 i

在執行第 9 行 arr[0]() 時,for 迴圈已經跑完了,這時候的全域變數 i 的值已經是 5 了,這就是為什麼最後 console.log(i) 的結果會是 5

解決方法一

利用閉包的原理,寫一個這樣的 function:
logN() 裡面傳入什麼值,就會印出什麼值

// 利用閉包的原理
function logN(n) {
  return function () {
    console.log(n);
  };
}

const log2 = logN(2);
log2(); // 2

就可以把 for 迴圈裡面的程式碼改成 arr[i] = logN(i);

var arr = [];
var i; // i 是全域變數
for (i = 0; i < 5; i++) {
  arr[i] = logN(i);
}

// 利用閉包的原理
function logN(n) {
  return function () {
    console.log(n);
  };
}

arr[0](); // 呼叫 arr[0] 這個 function,會印出 0

因為 logN(i) 會回傳一個新的 function,所以就會有一個新的作用域可以去記住當時的 i 的值

因為 ilogN(i) 所傳進去的參數,所以這個 i 會被記在 function logN 的 Activation Object 裡面

改成這樣之後,就會是我想要的結果了:
執行 arr[0]() 就輸出 0,執行 arr[1]() 就輸出 1

用 IIFE 立即執行函式(通常是給匿名函式用的)

一般我要呼叫一個 function,就是用 test() 這種方式

function test() {
  console.log("hello");
}

test(); // 呼叫 function test

那要怎麼呼叫一個匿名的 function 呢?
在 JS 裡面,如果要呼叫一個「匿名的 function」,可以用 IIFE (immediately-invoked function expression)這個方法,中文翻作「立即呼叫函式表達式」,範例如下:
把 function 用一個小括號包起來,後面再加上一個小括號(裡面可以傳參數進去)

(function (num) {
  console.log(num);
})(123); // 立即呼叫函式,會輸出 123

用 IIFE 也是可以呼叫一個有名字的函式:

(function test() {
  console.log("hello");
})(); // 立即呼叫函式,會輸出 hello

解決方法二

所以上面的 for 迴圈範例,就可以改成這樣:
改成用 IIFE 的好處是「不用再額外宣告一個 logN 函式」

var arr = [];
var i; // i 是全域變數
for (i = 0; i < 5; i++) {
  arr[i] = (function (number) {
    return function () {
      console.log(number);
    };
  })(i);
}

arr[1](); // 呼叫 arr[0] 這個 function,會印出 1

解決方法三

在 for 迴圈裡面,改為用 let 宣告 i,就可以了

var arr = [];
// 改為用 let 宣告 i
for (let i = 0; i < 5; i++) {
  arr[i] = function () {
    console.log(i);
  };
}

arr[1](); // 呼叫 arr[0] 這個 function,會印出 1
arr[3](); // 呼叫 arr[0] 這個 function,會印出 3

在 for 迴圈裡面,let 的表現不太一樣

let 宣告的變數,作用域會是一個 block

for 迴圈跑 5 圈,可以想成是「會產生 5 個 block」,每一圈都會有一個「自己的作用域」,像是這樣:

所以,每一圈的 i,就會跟 arr[i]i 有相同的值

// 迴圈第 1 圈
{
  let i = 0;
  arr[0] = function() {
    console.log(i)
  }
}

// 迴圈第 2 圈
{
  let i = 1;
  arr[1] = function() {
    console.log(i)
  }
}

// 迴圈第 3 圈
{
  let i = 2;
  arr[2] = function() {
    console.log(i)
  }
}

... 以此類推

Closure 可以應用在哪裡?

用 Closure 來隱藏某些資訊

舉一個例子:
雖然我寫了兩個 function 去操控 money
但是,我會碰到的問題是:如果有其他同事跟我協作,他完全可以不透過這兩個 function,就任意把 money 的值改掉,這是我完全沒辦法預防的

var money = 99;

function add(num) {
  money += num;
}

function deduct(num) {
  if (num >= 10) {
    money -= 10;
  } else {
    money -= num;
  }
}

add(1); // money = 100
deduct(100); // money = 90

money = -1; // 被同事亂改成 -1
console.log(money); // -1

這時,就可以用閉包來解決這個問題:

利用閉包的特性,把這些 function 給封裝起來,會比較安全

function createWallet(initMoney) {
  var money = initMoney;
  return {
    add: function (num) {
      money += num;
    },
    deduct: function (num) {
      if (num >= 10) {
        money -= 10;
      } else {
        money -= num;
      }
    },
    getMoney: function () {
      return money;
    },
  };
}

let myWallet = createWallet(99);
myWallet.add(1);
myWallet.deduct(100);
console.log(myWallet.getMoney()); // 90

改成用這樣寫之後,其他人就沒辦法用什麼 myWallet.money = -1; 來把我的 money 亂改值了

其他人沒辦法從「外部」去操控 money 的值,只能使用 createWallet 回傳回來的這些 function 來操控我傳進去的 money 的值


#javascript







Related Posts

The introduction and difference between class component and function component in React

The introduction and difference between class component and function component in React

How to Launch an Amazon EC2 Instance

How to Launch an Amazon EC2 Instance

Option API#watch 以及 computed

Option API#watch 以及 computed


Comments